{
 "cells": [
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "#|default_exp models.MINIROCKET_Pytorch"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# MINIROCKET Pytorch"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    ">A Very Fast (Almost) Deterministic Transform for Time Series Classification.\n",
    "\n",
    "This is a Pytorch implementation of MiniRocket developed by Malcolm McLean and Ignacio Oguiza based on:\n",
    "\n",
    "Dempster, A., Schmidt, D. F., & Webb, G. I. (2020). MINIROCKET: A Very Fast (Almost) Deterministic Transform for Time Series Classification. arXiv preprint arXiv:2012.08791.\n",
    "\n",
    "Original paper: https://arxiv.org/abs/2012.08791\n",
    "\n",
    "Original code:  https://github.com/angus924/minirocket"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "#|export\n",
    "import torch\n",
    "import torch.nn as nn\n",
    "import torch.nn.functional as F\n",
    "import numpy as np\n",
    "from collections import OrderedDict"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "#|export\n",
    "class MiniRocketFeatures(nn.Module):\n",
    "    \"\"\"This is a Pytorch implementation of MiniRocket developed by Malcolm McLean and Ignacio Oguiza\n",
    "    \n",
    "    MiniRocket paper citation:\n",
    "    @article{dempster_etal_2020,\n",
    "      author  = {Dempster, Angus and Schmidt, Daniel F and Webb, Geoffrey I},\n",
    "      title   = {{MINIROCKET}: A Very Fast (Almost) Deterministic Transform for Time Series Classification},\n",
    "      year    = {2020},\n",
    "      journal = {arXiv:2012.08791}\n",
    "    }\n",
    "    Original paper: https://arxiv.org/abs/2012.08791\n",
    "    Original code:  https://github.com/angus924/minirocket\"\"\"\n",
    "\n",
    "    kernel_size, num_kernels, fitting = 9, 84, False\n",
    "\n",
    "    def __init__(self, c_in, seq_len, num_features=10_000, max_dilations_per_kernel=32, random_state=None):\n",
    "        super(MiniRocketFeatures, self).__init__()\n",
    "        self.c_in, self.seq_len = c_in, seq_len\n",
    "        self.num_features = num_features // self.num_kernels * self.num_kernels\n",
    "        self.max_dilations_per_kernel  = max_dilations_per_kernel\n",
    "        self.random_state = random_state\n",
    "\n",
    "        # Convolution\n",
    "        indices = torch.combinations(torch.arange(self.kernel_size), 3).unsqueeze(1)\n",
    "        kernels = (-torch.ones(self.num_kernels, 1, self.kernel_size)).scatter_(2, indices, 2)\n",
    "        self.kernels = nn.Parameter(kernels.repeat(c_in, 1, 1), requires_grad=False)\n",
    "\n",
    "        # Dilations & padding\n",
    "        self._set_dilations(seq_len)\n",
    "\n",
    "        # Channel combinations (multivariate)\n",
    "        if c_in > 1:\n",
    "            self._set_channel_combinations(c_in)\n",
    "\n",
    "        # Bias\n",
    "        for i in range(self.num_dilations):\n",
    "            self.register_buffer(f'biases_{i}', torch.empty((self.num_kernels, self.num_features_per_dilation[i])))\n",
    "        self.register_buffer('prefit', torch.BoolTensor([False]))\n",
    "        \n",
    "    def fit(self, X, chunksize=None):\n",
    "        num_samples = X.shape[0]\n",
    "        if chunksize is None:\n",
    "            chunksize = min(num_samples, self.num_dilations * self.num_kernels)\n",
    "        else: \n",
    "            chunksize = min(num_samples, chunksize)\n",
    "        np.random.seed(self.random_state)\n",
    "        idxs = np.random.choice(num_samples, chunksize, False)\n",
    "        self.fitting = True\n",
    "        if isinstance(X, np.ndarray): \n",
    "            self(torch.from_numpy(X[idxs]).to(self.kernels.device))\n",
    "        else:\n",
    "            self(X[idxs].to(self.kernels.device))\n",
    "        self.fitting = False\n",
    "    \n",
    "    def forward(self, x):\n",
    "        _features = []\n",
    "        for i, (dilation, padding) in enumerate(zip(self.dilations, self.padding)):\n",
    "            _padding1 = i%2\n",
    "            \n",
    "            # Convolution\n",
    "            C = F.conv1d(x, self.kernels, padding=padding, dilation=dilation, groups=self.c_in)\n",
    "            if self.c_in > 1: # multivariate\n",
    "                C = C.reshape(x.shape[0], self.c_in, self.num_kernels, -1)\n",
    "                channel_combination = getattr(self, f'channel_combinations_{i}')\n",
    "                C = torch.mul(C, channel_combination)\n",
    "                C = C.sum(1)\n",
    "\n",
    "            # Bias\n",
    "            if not self.prefit or self.fitting:\n",
    "                num_features_this_dilation = self.num_features_per_dilation[i]\n",
    "                bias_this_dilation = self._get_bias(C, num_features_this_dilation)\n",
    "                setattr(self, f'biases_{i}', bias_this_dilation)        \n",
    "                if self.fitting:\n",
    "                    if i < self.num_dilations - 1:\n",
    "                        continue\n",
    "                    else:\n",
    "                        self.prefit = torch.BoolTensor([True])\n",
    "                        return\n",
    "                elif i == self.num_dilations - 1:\n",
    "                    self.prefit = torch.BoolTensor([True])\n",
    "            else:\n",
    "                bias_this_dilation = getattr(self, f'biases_{i}')\n",
    "            \n",
    "            # Features\n",
    "            _features.append(self._get_PPVs(C[:, _padding1::2], bias_this_dilation[_padding1::2]))\n",
    "            _features.append(self._get_PPVs(C[:, 1-_padding1::2, padding:-padding], bias_this_dilation[1-_padding1::2]))\n",
    "        return torch.cat(_features, dim=1)           \n",
    "\n",
    "    def _get_PPVs(self, C, bias):\n",
    "        C = C.unsqueeze(-1)\n",
    "        bias = bias.view(1, bias.shape[0], 1, bias.shape[1])\n",
    "        return (C > bias).float().mean(2).flatten(1)\n",
    "\n",
    "    def _set_dilations(self, input_length):\n",
    "        num_features_per_kernel = self.num_features // self.num_kernels\n",
    "        true_max_dilations_per_kernel = min(num_features_per_kernel, self.max_dilations_per_kernel)\n",
    "        multiplier = num_features_per_kernel / true_max_dilations_per_kernel\n",
    "        max_exponent = np.log2((input_length - 1) / (9 - 1))\n",
    "        dilations, num_features_per_dilation = \\\n",
    "        np.unique(np.logspace(0, max_exponent, true_max_dilations_per_kernel, base = 2).astype(np.int32), return_counts = True)\n",
    "        num_features_per_dilation = (num_features_per_dilation * multiplier).astype(np.int32)\n",
    "        remainder = num_features_per_kernel - num_features_per_dilation.sum()\n",
    "        i = 0\n",
    "        while remainder > 0:\n",
    "            num_features_per_dilation[i] += 1\n",
    "            remainder -= 1\n",
    "            i = (i + 1) % len(num_features_per_dilation)\n",
    "        self.num_features_per_dilation = num_features_per_dilation\n",
    "        self.num_dilations = len(dilations)\n",
    "        self.dilations = dilations\n",
    "        self.padding = []\n",
    "        for i, dilation in enumerate(dilations): \n",
    "            self.padding.append((((self.kernel_size - 1) * dilation) // 2))\n",
    "\n",
    "    def _set_channel_combinations(self, num_channels):\n",
    "        num_combinations = self.num_kernels * self.num_dilations\n",
    "        max_num_channels = min(num_channels, 9)\n",
    "        max_exponent_channels = np.log2(max_num_channels + 1)\n",
    "        np.random.seed(self.random_state)\n",
    "        num_channels_per_combination = (2 ** np.random.uniform(0, max_exponent_channels, num_combinations)).astype(np.int32)\n",
    "        channel_combinations = torch.zeros((1, num_channels, num_combinations, 1))\n",
    "        for i in range(num_combinations):\n",
    "            channel_combinations[:, np.random.choice(num_channels, num_channels_per_combination[i], False), i] = 1\n",
    "        channel_combinations = torch.split(channel_combinations, self.num_kernels, 2) # split by dilation\n",
    "        for i, channel_combination in enumerate(channel_combinations): \n",
    "            self.register_buffer(f'channel_combinations_{i}', channel_combination) # per dilation\n",
    "\n",
    "    def _get_quantiles(self, n):\n",
    "        return torch.tensor([(_ * ((np.sqrt(5) + 1) / 2)) % 1 for _ in range(1, n + 1)]).float()\n",
    "\n",
    "    def _get_bias(self, C, num_features_this_dilation):\n",
    "        np.random.seed(self.random_state)\n",
    "        idxs = np.random.choice(C.shape[0], self.num_kernels)\n",
    "        samples = C[idxs].diagonal().T \n",
    "        biases = torch.quantile(samples, self._get_quantiles(num_features_this_dilation).to(C.device), dim=1).T\n",
    "        return biases\n",
    "\n",
    "MRF = MiniRocketFeatures"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "#|export \n",
    "def get_minirocket_features(o, model, chunksize=1024, use_cuda=None, to_np=True):\n",
    "    \"\"\"Function used to split a large dataset into chunks, avoiding OOM error.\"\"\"\n",
    "    use = torch.cuda.is_available() if use_cuda is None else use_cuda\n",
    "    device = torch.device(torch.cuda.current_device()) if use else torch.device('cpu')\n",
    "    model = model.to(device)\n",
    "    if isinstance(o, np.ndarray): o = torch.from_numpy(o).to(device)\n",
    "    _features = []\n",
    "    for oi in torch.split(o, chunksize): \n",
    "        _features.append(model(oi))\n",
    "    features = torch.cat(_features).unsqueeze(-1)\n",
    "    if to_np: return features.cpu().numpy()\n",
    "    else: return features"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "#|export\n",
    "class MiniRocketHead(nn.Sequential):\n",
    "    def __init__(self, c_in, c_out, seq_len=1, bn=True, fc_dropout=0.):\n",
    "        layers = [nn.Flatten()]\n",
    "        if bn:\n",
    "            layers += [nn.BatchNorm1d(c_in)]\n",
    "        if fc_dropout:\n",
    "            layers += [nn.Dropout(fc_dropout)]\n",
    "        linear = nn.Linear(c_in, c_out)\n",
    "        nn.init.constant_(linear.weight.data, 0)\n",
    "        nn.init.constant_(linear.bias.data, 0)\n",
    "        layers += [linear]\n",
    "        head = nn.Sequential(*layers)\n",
    "        super().__init__(OrderedDict(\n",
    "            [('backbone', nn.Sequential()), ('head', head)]))"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "#|export\n",
    "class MiniRocket(nn.Sequential):\n",
    "    def __init__(self, c_in, c_out, seq_len, num_features=10_000, max_dilations_per_kernel=32, random_state=None, bn=True, fc_dropout=0):\n",
    "        \n",
    "        # Backbone\n",
    "        backbone =  MiniRocketFeatures(c_in, seq_len, num_features=num_features, max_dilations_per_kernel=max_dilations_per_kernel, \n",
    "                                       random_state=random_state)\n",
    "        num_features = backbone.num_features\n",
    "\n",
    "        # Head\n",
    "        self.head_nf = num_features\n",
    "        layers = [nn.Flatten()]\n",
    "        if bn: layers += [nn.BatchNorm1d(num_features)]\n",
    "        if fc_dropout: layers += [nn.Dropout(fc_dropout)]   \n",
    "        linear = nn.Linear(num_features, c_out)\n",
    "        nn.init.constant_(linear.weight.data, 0)\n",
    "        nn.init.constant_(linear.bias.data, 0) \n",
    "        layers += [linear]\n",
    "        head = nn.Sequential(*layers)\n",
    "\n",
    "        super().__init__(OrderedDict([('backbone', backbone), ('head', head)]))\n",
    "\n",
    "    def fit(self, X, chunksize=None):\n",
    "        self.backbone.fit(X, chunksize=chunksize)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "from tsai.imports import default_device\n",
    "from fastai.metrics import accuracy\n",
    "from fastai.callback.tracker import ReduceLROnPlateau\n",
    "from tsai.data.all import *\n",
    "from tsai.learner import *"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [
    {
     "data": {
      "text/html": [
       "<table border=\"1\" class=\"dataframe\">\n",
       "  <thead>\n",
       "    <tr style=\"text-align: left;\">\n",
       "      <th>epoch</th>\n",
       "      <th>train_loss</th>\n",
       "      <th>valid_loss</th>\n",
       "      <th>accuracy</th>\n",
       "      <th>time</th>\n",
       "    </tr>\n",
       "  </thead>\n",
       "  <tbody>\n",
       "    <tr>\n",
       "      <td>0</td>\n",
       "      <td>0.693147</td>\n",
       "      <td>0.530879</td>\n",
       "      <td>0.752613</td>\n",
       "      <td>00:00</td>\n",
       "    </tr>\n",
       "  </tbody>\n",
       "</table>"
      ],
      "text/plain": [
       "<IPython.core.display.HTML object>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "# Offline feature calculation\n",
    "dsid = 'ECGFiveDays'\n",
    "X, y, splits = get_UCR_data(dsid, split_data=False)\n",
    "mrf = MiniRocketFeatures(c_in=X.shape[1], seq_len=X.shape[2]).to(default_device())\n",
    "X_train = X[splits[0]]  # X_train may either be a np.ndarray or a torch.Tensor\n",
    "mrf.fit(X_train)\n",
    "X_tfm = get_minirocket_features(X, mrf)\n",
    "tfms = [None, TSClassification()]\n",
    "batch_tfms = TSStandardize(by_var=True)\n",
    "dls = get_ts_dls(X_tfm, y, splits=splits, tfms=tfms, batch_tfms=batch_tfms, bs=256)\n",
    "learn = ts_learner(dls, MiniRocketHead, metrics=accuracy)\n",
    "learn.fit(1, 1e-4, cbs=ReduceLROnPlateau(factor=0.5, min_lr=1e-8, patience=10))"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [
    {
     "data": {
      "text/html": [
       "<table border=\"1\" class=\"dataframe\">\n",
       "  <thead>\n",
       "    <tr style=\"text-align: left;\">\n",
       "      <th>epoch</th>\n",
       "      <th>train_loss</th>\n",
       "      <th>valid_loss</th>\n",
       "      <th>accuracy</th>\n",
       "      <th>time</th>\n",
       "    </tr>\n",
       "  </thead>\n",
       "  <tbody>\n",
       "    <tr>\n",
       "      <td>0</td>\n",
       "      <td>0.693147</td>\n",
       "      <td>0.713297</td>\n",
       "      <td>0.502904</td>\n",
       "      <td>00:06</td>\n",
       "    </tr>\n",
       "  </tbody>\n",
       "</table>"
      ],
      "text/plain": [
       "<IPython.core.display.HTML object>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "# Online feature calculation\n",
    "dsid = 'ECGFiveDays'\n",
    "X, y, splits = get_UCR_data(dsid, split_data=False)\n",
    "tfms = [None, TSClassification()]\n",
    "batch_tfms = TSStandardize()\n",
    "dls = get_ts_dls(X, y, splits=splits, tfms=tfms, batch_tfms=batch_tfms, bs=256)\n",
    "learn = ts_learner(dls, MiniRocket, metrics=accuracy)\n",
    "learn.fit_one_cycle(1, 1e-2)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [
    {
     "data": {
      "application/javascript": "IPython.notebook.save_checkpoint();",
      "text/plain": [
       "<IPython.core.display.Javascript object>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "/Users/nacho/notebooks/tsai/nbs/056_models.MINIROCKET_Pytorch.ipynb saved at 2023-02-19 20:57:08\n",
      "Correct notebook to script conversion! 😃\n",
      "Sunday 19/02/23 20:57:10 CET\n"
     ]
    },
    {
     "data": {
      "text/html": [
       "\n",
       "                <audio  controls=\"controls\" autoplay=\"autoplay\">\n",
       "                    <source src=\"data:audio/wav;base64,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\" type=\"audio/wav\" />\n",
       "                    Your browser does not support the audio element.\n",
       "                </audio>\n",
       "              "
      ],
      "text/plain": [
       "<IPython.lib.display.Audio object>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "#|eval: false\n",
    "#|hide\n",
    "from tsai.export import get_nb_name; nb_name = get_nb_name(locals())\n",
    "from tsai.imports import create_scripts; create_scripts(nb_name)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": []
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "python3",
   "language": "python",
   "name": "python3"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 4
}
